/**
 * BSD-style license; for more info see http://pmd.sourceforge.net/license.html
 */
package io.github.yangziwen.pmd;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.diff.HistogramDiff;

import io.github.yangziwen.diff.calculate.DiffCalculator;
import io.github.yangziwen.diff.calculate.DiffEntryWrapper;
import io.github.yangziwen.pmd.cli.PMDCommandLineInterface;
import io.github.yangziwen.pmd.cli.PMDParameters;
import io.github.yangziwen.pmd.filter.DiffLineFilter;
import net.sourceforge.pmd.PMD;
import net.sourceforge.pmd.PMDException;
import net.sourceforge.pmd.Report;
import net.sourceforge.pmd.ReportListener;
import net.sourceforge.pmd.Rule;
import net.sourceforge.pmd.RuleContext;
import net.sourceforge.pmd.RuleSet;
import net.sourceforge.pmd.RuleSetFactory;
import net.sourceforge.pmd.RuleSets;
import net.sourceforge.pmd.RuleViolation;
import net.sourceforge.pmd.RulesetsFactoryUtils;
import net.sourceforge.pmd.SourceCodeProcessor;
import net.sourceforge.pmd.benchmark.Benchmark;
import net.sourceforge.pmd.benchmark.Benchmarker;
import net.sourceforge.pmd.benchmark.TextReport;
import net.sourceforge.pmd.lang.Language;
import net.sourceforge.pmd.lang.LanguageFilenameFilter;
import net.sourceforge.pmd.lang.LanguageVersion;
import net.sourceforge.pmd.lang.LanguageVersionDiscoverer;
import net.sourceforge.pmd.lang.LanguageVersionHandler;
import net.sourceforge.pmd.lang.Parser;
import net.sourceforge.pmd.lang.ParserOptions;
import net.sourceforge.pmd.processor.MonoThreadProcessor;
import net.sourceforge.pmd.processor.MultiThreadProcessor;
import net.sourceforge.pmd.renderers.Renderer;
import net.sourceforge.pmd.stat.Metric;
import net.sourceforge.pmd.util.ClasspathClassLoader;
import net.sourceforge.pmd.util.FileUtil;
import net.sourceforge.pmd.util.IOUtil;
import net.sourceforge.pmd.util.database.DBMSMetadata;
import net.sourceforge.pmd.util.database.DBURI;
import net.sourceforge.pmd.util.database.SourceObject;
import net.sourceforge.pmd.util.datasource.DataSource;
import net.sourceforge.pmd.util.datasource.ReaderDataSource;
import net.sourceforge.pmd.util.log.ConsoleLogHandler;
import net.sourceforge.pmd.util.log.ScopedLogHandlersManager;
import org.eclipse.jgit.diff.RawTextComparator;

/**
 * This is the main class for interacting with PMD. The primary flow of all Rule
 * process is controlled via interactions with this class. A command line
 * interface is supported, as well as a programmatic API for integrating PMD
 * with other software such as IDEs and Ant.
 */
public class Main {

    private static final Logger LOG = Logger.getLogger(PMD.class.getName());

    /** The line delimiter used by PMD in outputs. Usually the platform specific line separator. */
    public static final String EOL = System.getProperty("line.separator", "\n");

    /** The default suppress marker string. */
    public static final String SUPPRESS_MARKER = "NOPMD";

    /** Contains the configuration with which this PMD instance has been created. */
    protected final PMDConfiguration configuration;

    private final SourceCodeProcessor rulesetsFileProcessor;

    /**
     * Constant that contains always the current version of PMD.
     */
    public static final String VERSION;

    /**
     * The diff entry list
     */
    public static final List<DiffEntryWrapper> DIFF_ENTRY_LIST = new ArrayList<>();

    /**
     * Create a PMD instance using a default Configuration. Changes to the
     * configuration may be required.
     */
    public Main() {
        this(new PMDConfiguration());
    }

    /**
     * Create a PMD instance using the specified Configuration.
     *
     * @param configuration
     *            The runtime Configuration of PMD to use.
     */
    public Main(PMDConfiguration configuration) {
        this.configuration = configuration;
        this.rulesetsFileProcessor = new SourceCodeProcessor(configuration);
    }

    /**
     * Parses the given string as a database uri and returns a list of datasources.
     * @param uriString the URI to parse
     * @return list of data sources
     * @throws PMDException if the URI couldn't be parsed
     * @see DBURI
     */
    public static List<DataSource> getURIDataSources(String uriString) throws PMDException {
        List<DataSource> dataSources = new ArrayList<>();

        try {
            DBURI dbUri = new DBURI(uriString);
            DBMSMetadata dbmsMetadata = new DBMSMetadata(dbUri);
            LOG.log(Level.FINE, "DBMSMetadata retrieved");
            List<SourceObject> sourceObjectList = dbmsMetadata.getSourceObjectList();
            LOG.log(Level.FINE, "Located {0} database source objects", sourceObjectList.size());
            for (SourceObject sourceObject : sourceObjectList) {
                String falseFilePath = sourceObject.getPseudoFileName();
                LOG.log(Level.FINEST, "Adding database source object {0}", falseFilePath);

                try {
                    dataSources.add(new ReaderDataSource(dbmsMetadata.getSourceCode(sourceObject), falseFilePath));
                } catch (SQLException ex) {
                    if (LOG.isLoggable(Level.WARNING)) {
                        LOG.log(Level.WARNING, "Cannot get SourceCode for " + falseFilePath + "  - skipping ...", ex);
                    }
                }
            }
        } catch (URISyntaxException e) {
            throw new PMDException("Cannot get DataSources from DBURI - \"" + uriString + "\"", e);
        } catch (SQLException e) {
            throw new PMDException("Cannot get DataSources from DBURI, couldn't access the database - \"" + uriString
                    + "\"", e);
        } catch (ClassNotFoundException e) {
            throw new PMDException("Cannot get DataSources from DBURI, probably missing database jdbc driver - \""
                    + uriString + "\"", e);
        } catch (Exception e) {
            throw new PMDException("Encountered unexpected problem with URI \""
                    + uriString + "\"", e);
        }
        return dataSources;
    }

    /**
     * Helper method to get a configured parser for the requested language. The parser is
     * configured based on the given {@link PMDConfiguration}.
     * @param languageVersion the requested language
     * @param configuration the given configuration
     * @return the pre-configured parser
     */
    public static Parser parserFor(LanguageVersion languageVersion, PMDConfiguration configuration) {

        // TODO Handle Rules having different parser options.
        LanguageVersionHandler languageVersionHandler = languageVersion.getLanguageVersionHandler();
        ParserOptions options = languageVersionHandler.getDefaultParserOptions();
        if (configuration != null) {
            options.setSuppressMarker(configuration.getSuppressMarker());
        }
        return languageVersionHandler.getParser(options);
    }

    /**
     * Create a report, filter out any defective rules, and keep a record of
     * them.
     *
     * @param rs the rules
     * @param ctx the rule context
     * @param fileName the filename of the source file, which should appear in the report
     * @return the Report
     */
    public static Report setupReport(RuleSets rs, RuleContext ctx, String fileName) {

        Set<Rule> brokenRules = removeBrokenRules(rs);
        Report report = Report.createReport(ctx, fileName);

        for (Rule rule : brokenRules) {
            report.addConfigError(new Report.RuleConfigurationError(rule, rule.dysfunctionReason()));
        }

        return report;
    }

    /**
     * Remove and return the misconfigured rules from the rulesets and log them
     * for good measure.
     *
     * @param ruleSets
     *            RuleSets
     * @return Set<Rule>
     */
    private static Set<Rule> removeBrokenRules(RuleSets ruleSets) {

        Set<Rule> brokenRules = new HashSet<>();
        ruleSets.removeDysfunctionalRules(brokenRules);

        for (Rule rule : brokenRules) {
            if (LOG.isLoggable(Level.WARNING)) {
                LOG.log(Level.WARNING,
                    "Removed misconfigured rule: " + rule.getName() + "  cause: " + rule.dysfunctionReason());
            }
        }

        return brokenRules;
    }

    /**
     * Get the runtime configuration. The configuration can be modified to
     * affect how PMD behaves.
     *
     * @return The configuration.
     * @see PMDConfiguration
     */
    public PMDConfiguration getConfiguration() {
        return configuration;
    }

    /**
     * Gets the source code processor.
     * @return SourceCodeProcessor
     */
    public SourceCodeProcessor getSourceCodeProcessor() {
        return rulesetsFileProcessor;
    }

    /**
     * This method is the main entry point for command line usage.
     *
     * @param configuration the configure to use
     * @return number of violations found.
     */
    public static int doPMD(PMDConfiguration configuration) {

        // Load the RuleSets
        RuleSetFactory ruleSetFactory = RulesetsFactoryUtils.getRulesetFactory(configuration);
        RuleSets ruleSets = RulesetsFactoryUtils.getRuleSetsWithBenchmark(configuration.getRuleSets(), ruleSetFactory);
        if (ruleSets == null) {
            return 0;
        }

        Set<Language> languages = getApplicableLanguages(configuration, ruleSets);
        List<DataSource> files = Collections.emptyList();
        if (StringUtils.isNotBlank(configuration.getGitDir())) {
            files = getApplicableGitDiffFiles(configuration, languages);
        } else {
            files = getApplicableFiles(configuration, languages);
        }

        long reportStart = System.nanoTime();
        try {
            Renderer renderer = configuration.createRenderer();
            List<Renderer> renderers = Collections.singletonList(renderer);

            renderer.setWriter(IOUtil.createWriter(configuration.getReportFile()));
            renderer.start();

            Benchmarker.mark(Benchmark.Reporting, System.nanoTime() - reportStart, 0);

            RuleContext ctx = new RuleContext();
            final AtomicInteger violations = new AtomicInteger(0);
            DiffLineFilter filter = new DiffLineFilter(Main.DIFF_ENTRY_LIST);
            ctx.getReport().addListener(new ReportListener() {
                @Override
                public void ruleViolationAdded(RuleViolation ruleViolation) {
                    if (!filter.accept(ruleViolation)) {
                        return;
                    }
                    violations.incrementAndGet();
                }
                @Override
                public void metricAdded(Metric metric) {
                }
            });

            processFiles(configuration, ruleSetFactory, files, ctx, renderers);

            reportStart = System.nanoTime();
            renderer.end();
            renderer.flush();
            return violations.get();
        } catch (Exception e) {
            String message = e.getMessage();
            if (message != null) {
                LOG.severe(message);
            } else {
                LOG.log(Level.SEVERE, "Exception during processing", e);
            }
            LOG.log(Level.FINE, "Exception during processing", e);
            LOG.info(PMDCommandLineInterface.buildUsageText());
            return 0;
        } finally {
            Benchmarker.mark(Benchmark.Reporting, System.nanoTime() - reportStart, 0);
        }
    }

    /**
     * Creates a new rule context, initialized with a new, empty report.
     *
     * @param sourceCodeFilename the source code filename
     * @param sourceCodeFile the source code file
     * @return the rule context
     */
    public static RuleContext newRuleContext(String sourceCodeFilename, File sourceCodeFile) {

        RuleContext context = new RuleContext();
        context.setSourceCodeFile(sourceCodeFile);
        context.setSourceCodeFilename(sourceCodeFilename);
        context.setReport(new Report());
        return context;
    }

    /**
     * A callback that would be implemented by IDEs keeping track of PMD's
     * progress as it evaluates a set of files.
     *
     * @author Brian Remedios
     */
    public interface ProgressMonitor {
        /**
         * A status update reporting on current progress. Implementers will
         * return true if it is to continue, false otherwise.
         *
         * @param total total number of files to be analyzed
         * @param totalDone number of files, that have been done analyzing.
         * @return <code>true</code> if the execution of PMD should continue, <code>false</code> if the execution
         * should be cancelled/terminated.
         */
        boolean status(int total, int totalDone);
    }

    /**
     * An entry point that would typically be used by IDEs intent on providing
     * ongoing feedback and the ability to terminate it at will.
     *
     * @param configuration the PMD configuration to use
     * @param ruleSetFactory ruleset factory
     * @param files the files to analyze
     * @param ctx the rule context to use for the execution
     * @param monitor PMD informs about the progress through this progress monitor. It provides also
     * the ability to terminate/cancel the execution.
     */
    public static void processFiles(PMDConfiguration configuration, RuleSetFactory ruleSetFactory,
            Collection<File> files, RuleContext ctx, ProgressMonitor monitor) {

        // TODO
        // call the main processFiles with just the new monitor and a single
        // logRenderer
    }

    /**
     * Run PMD on a list of files using multiple threads - if more than one is
     * available
     *
     * @param configuration
     *            Configuration
     * @param ruleSetFactory
     *            RuleSetFactory
     * @param files
     *            List<DataSource>
     * @param ctx
     *            RuleContext
     * @param renderers
     *            List<Renderer>
     */
    public static void processFiles(final PMDConfiguration configuration, final RuleSetFactory ruleSetFactory,
            final List<DataSource> files, final RuleContext ctx, final List<Renderer> renderers) {

        sortFiles(configuration, files);

        /*
         * Check if multithreaded support is available. ExecutorService can also
         * be disabled if threadCount is not positive, e.g. using the
         * "-threads 0" command line option.
         */
        if (configuration.getThreads() > 0) {
            new MultiThreadProcessor(configuration).processFiles(ruleSetFactory, files, ctx, renderers);
        } else {
            new MonoThreadProcessor(configuration).processFiles(ruleSetFactory, files, ctx, renderers);
        }

        if (configuration.getClassLoader() instanceof ClasspathClassLoader) {
            IOUtil.tryCloseClassLoader(configuration.getClassLoader());
        }
    }

    private static void sortFiles(final PMDConfiguration configuration, final List<DataSource> files) {
        if (configuration.isStressTest()) {
            // randomize processing order
            Collections.shuffle(files);
        } else {
            final boolean useShortNames = configuration.isReportShortNames();
            final String inputPaths = configuration.getInputPaths();
            Collections.sort(files, new Comparator<DataSource>() {
                @Override
                public int compare(DataSource left, DataSource right) {
                    String leftString = left.getNiceFileName(useShortNames, inputPaths);
                    String rightString = right.getNiceFileName(useShortNames, inputPaths);
                    return leftString.compareTo(rightString);
                }
            });
        }
    }

    /**
     * Determines all the diff files, that should be analyzed by PMD.
     * @param configuration contains either the file path or the DB URI, from where to load the files
     * @param languages used to filter by file extension
     * @return List<DataSource> of files
     */
    public static List<DataSource> getApplicableGitDiffFiles(PMDConfiguration configuration, Set<Language> languages) {
        long startFiles = System.nanoTime();
        List<File> diffFiles = calculateGitDiffFiles(configuration, languages);
        if (diffFiles == null || diffFiles.isEmpty()) {
            System.exit(0);
        }
        String paths = diffFiles.stream().map(File::getAbsolutePath).collect(Collectors.joining(","));
        configuration.setInputPaths(paths);
        List<DataSource> files = internalGetApplicableFiles(configuration, languages);
        long endFiles = System.nanoTime();
        Benchmarker.mark(Benchmark.CollectFiles, endFiles - startFiles, 0);
        return files;
    }

     private static List<File> calculateGitDiffFiles(PMDConfiguration configuration, Set<Language> languages) {
        LanguageFilenameFilter fileSelector = new LanguageFilenameFilter(languages);
        File repoDir = new File(configuration.getGitDir());
        if (!repoDir.isDirectory()) {
            System.out.println("git directory " + configuration.getGitDir() + " is not a directory!");
            System.exit(1);
        }
        String oldRev = configuration.getBaseRev();
        boolean includeStagedCodes = configuration.isIncludeStagedCodes();
        if (StringUtils.isBlank(oldRev)) {
            oldRev = includeStagedCodes ? "HEAD" : "HEAD~";
        }
        String newRev = "HEAD";
        DiffCalculator calculator = DiffCalculator.builder()
                .comparator(configuration.isIgnoreWhitespace() ? RawTextComparator.WS_IGNORE_ALL : RawTextComparator.DEFAULT)
                .diffAlgorithm(new HistogramDiff()).build();

        try {
            List<DiffEntryWrapper> diffEntryList = calculator.calculateDiff(repoDir, oldRev, newRev, includeStagedCodes)
                    .stream()
                    .filter(diffEntry -> !diffEntry.isDeleted())
                    .filter(diffEntry -> fileSelector.accept(diffEntry.getNewFile().getParentFile(), diffEntry.getNewFile().getName()))
                    .collect(Collectors.toList());

            if (StringUtils.isNotBlank(configuration.getExcludeRegexp())) {
                Pattern excludePattern = Pattern.compile(configuration.getExcludeRegexp());
                diffEntryList.removeIf(diffEntry -> excludePattern.matcher(diffEntry.getAbsoluteNewPath()).matches());
            }

             DIFF_ENTRY_LIST.addAll(diffEntryList);

             return diffEntryList.stream()
                    .map(DiffEntryWrapper::getNewFile)
                    .collect(Collectors.toList());

         } catch (Exception e) {
            e.printStackTrace();
            System.out.println("error happened when calculate git diff");
            return Collections.emptyList();
        }
    }

    /**
     * Determines all the files, that should be analyzed by PMD.
     * @param configuration contains either the file path or the DB URI, from where to load the files
     * @param languages used to filter by file extension
     * @return List<DataSource> of files
     */
    public static List<DataSource> getApplicableFiles(PMDConfiguration configuration, Set<Language> languages) {
        long startFiles = System.nanoTime();
        List<DataSource> files = internalGetApplicableFiles(configuration, languages);
        if (StringUtils.isNotBlank(configuration.getExcludeRegexp())) {
            Pattern excludePattern = Pattern.compile(configuration.getExcludeRegexp());
            files.removeIf(file -> excludePattern.matcher(file.getNiceFileName(false, "")).matches());
        }
        long endFiles = System.nanoTime();
        Benchmarker.mark(Benchmark.CollectFiles, endFiles - startFiles, 0);
        return files;
    }

    private static List<DataSource> internalGetApplicableFiles(PMDConfiguration configuration, Set<Language> languages) {
        LanguageFilenameFilter fileSelector = new LanguageFilenameFilter(languages);
        List<DataSource> files = new ArrayList<>();

        if (null != configuration.getInputPaths()) {
            files.addAll(FileUtil.collectFiles(configuration.getInputPaths(), fileSelector));
        }

        if (null != configuration.getInputUri()) {
            String uriString = configuration.getInputUri();
            try {
                List<DataSource> dataSources = getURIDataSources(uriString);

                files.addAll(dataSources);
            } catch (PMDException ex) {
                LOG.log(Level.SEVERE, "Problem with Input URI", ex);
                throw new RuntimeException("Problem with DBURI: " + uriString, ex);
            }
        }

        if (null != configuration.getInputFilePath()) {
            String inputFilePath = configuration.getInputFilePath();
            File file = new File(inputFilePath);
            try {
              if (!file.exists()) {
                LOG.log(Level.SEVERE, "Problem with Input File Path", inputFilePath);
                throw new RuntimeException("Problem with Input File Path: " + inputFilePath);
              } else {
                String filePaths = FileUtils.readFileToString(new File(inputFilePath));
                filePaths = StringUtils.trimToEmpty(filePaths);
                filePaths = filePaths.replaceAll("\\r?\\n", ",");
                filePaths = filePaths.replaceAll(",+", ",");

                files.addAll(FileUtil.collectFiles(filePaths, fileSelector));
              }
            } catch (IOException ex) {
              LOG.log(Level.SEVERE, "Problem with Input File", ex);
              throw new RuntimeException("Problem with Input File Path: " + inputFilePath, ex);
            }

        }
        return files;
    }

    private static Set<Language> getApplicableLanguages(PMDConfiguration configuration, RuleSets ruleSets) {
        Set<Language> languages = new HashSet<>();
        LanguageVersionDiscoverer discoverer = configuration.getLanguageVersionDiscoverer();

        for (Rule rule : ruleSets.getAllRules()) {
            Language language = rule.getLanguage();
            if (languages.contains(language)) {
                continue;
            }
            LanguageVersion version = discoverer.getDefaultLanguageVersion(language);
            if (RuleSet.applies(rule, version)) {
                languages.add(language);
                if (LOG.isLoggable(Level.FINE)) {
                    LOG.fine("Using " + language.getShortName() + " version: " + version.getShortName());
                }
            }
        }
        return languages;
    }

    /**
     * Entry to invoke PMD as command line tool
     *
     * @param args command line arguments
     */
    public static void main(String[] args) {
        PMDCommandLineInterface.run(args);
    }

    /**
     * Parses the command line arguments and executes PMD.
     * @param args command line arguments
     * @return the exit code, where <code>0</code> means successful execution, <code>1</code> means error,
     * <code>4</code> means there have been violations found.
     */
    public static int run(String[] args) {
        int status = 0;
        long start = System.nanoTime();
        final PMDParameters params = PMDCommandLineInterface.extractParameters(new PMDParameters(), args, "pmd");
        final PMDConfiguration configuration = PMDParameters.transformParametersIntoConfiguration(params);

        final Level logLevel = params.isDebug() ? Level.FINER : Level.INFO;
        final Handler logHandler = new ConsoleLogHandler();
        final ScopedLogHandlersManager logHandlerManager = new ScopedLogHandlersManager(logLevel, logHandler);
        final Level oldLogLevel = LOG.getLevel();
        LOG.setLevel(logLevel); // Need to do this, since the static logger has
                                // already been initialized at this point
        try {
            int violations = Main.doPMD(configuration);
            if (violations > 0 && configuration.isFailOnViolation()) {
                status = PMDCommandLineInterface.VIOLATIONS_FOUND;
            } else {
                status = 0;
            }
        } catch (Exception e) {
            System.out.println(PMDCommandLineInterface.buildUsageText());
            System.out.println();
            System.err.println(e.getMessage());
            status = PMDCommandLineInterface.ERROR_STATUS;
        } finally {
            logHandlerManager.close();
            LOG.setLevel(oldLogLevel);
            if (params.isBenchmark()) {
                long end = System.nanoTime();
                Benchmarker.mark(Benchmark.TotalPMD, end - start, 0);

                TextReport report = new TextReport(); // TODO get specified
                                                      // report format from
                                                      // config
                report.generate(Benchmarker.values(), System.err);
            }
        }
        return status;
    }

    /**
     * Determines the version from maven's generated pom.properties file.
     */
    static {
        String pmdVersion = null;
        InputStream stream = PMD.class.getResourceAsStream("/META-INF/maven/net.sourceforge.pmd/pmd-core/pom.properties");
        if (stream != null) {
            try {
                Properties properties = new Properties();
                properties.load(stream);
                pmdVersion = properties.getProperty("version");
            } catch (IOException e) {
                LOG.log(Level.FINE, "Couldn't determine version of PMD", e);
            } finally {
                IOUtils.closeQuietly(stream);
            }
        }
        if (pmdVersion == null) {
            pmdVersion = "unknown";
        }
        VERSION = pmdVersion;
    }
}
